Skip to content

Go 依赖注入:谁创建对象,谁把依赖交给它,谁负责收尾 ​

报价服务需要一个价格库。最直接的写法是让服务自己打开数据库,但这样测试、命令行程序和 HTTP 程序都得跟着它的初始化方式走。依赖注入把这一步移到外面:服务声明需要什么,由程序入口把已经准备好的依赖传进去。

这件事在 Go 里通常不需要框架。本文从一个普通构造函数开始,再处理两个更容易漏的问题:构造到一半失败怎么办,正常退出时按什么顺序释放资源。示例固定 Go 1.27.2、Linux/amd64。

先让参数说清依赖 ​

服务只需要“按 key 获取内容”,因此消费方定义一个方法的小接口:

go
type Store interface {
    Get(context.Context, string) (string, error)
}
func NewService(store Store) (*Service, error) {
    if store == nil { return nil, errors.New("store is required") }
    return &Service{store: store}, nil
}

构造函数接收 Store,返回具体 *Service。Go 的接口实现是隐式的;生产存储和测试替身只要满足方法集,就能传进来,不需要在接口所属包登记。接口规则

这里使用接口是因为确实存在“获取数据”这个变化边界,不是因为每个 struct 都必须配一个 interface。如果组件只依赖一个不会替换的具体对象,直接传 *Config 或 *Client 完全可以。消费方定义小接口、实现方通常返回具体类型,也是 Go 官方审查建议。Interfaces

写法依赖在哪里出现收益代价或问题
构造函数参数NewService(store)调用处一眼看见依赖,容易替换和测试入口需要维护组装代码
方法参数Lookup(ctx, store, key)适合每次操作不同的临时能力长期依赖反复传递会啰嗦
包级可变变量var defaultStore调用短测试互相影响,生命周期和替换时机隐藏
service locatorcontainer.Get("store")隐藏具体组装依赖藏进运行时查询,类型与缺失错误更难追踪

后两种不是语言禁止的写法;这里倾向显式参数,是为了降低本场景的隐藏状态。Wire 原始问题说明、Uber 的可变全局状态建议

在入口处把依赖图接起来 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    B["Build:程序组装入口"] --> S["打开 store"]
    S --> V["NewService(store)"]
    B --> C["打开 cache"]
    V --> A["App 持有 Service"]
    C --> A
    S --> A
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    B["Build:程序组装入口"] --> S["打开 store"]
    S --> V["NewService(store)"]
    B --> C["打开 cache"]
    V --> A["App 持有 Service"]
    C --> A
    S --> A

图表示本例的创建和持有关系。Service 只依赖 Store;cache 是 App 持有的第二个模拟资源,用于演示清理顺序,没有伪装成已经接入真实缓存服务。所谓“组装入口”只是集中创建和连接对象的地方,常在 main 附近,不是一种需要安装的新组件。

附录 Build 的成功路径打开 store、构造 service、打开 cache,再返回 App 及其 Close 能力。Example 测试实际产生并核对:

text
open store
open cache
value:pen <nil>
close cache
close store

依赖从参数进入、结果从方法返回,测试可以直接看到这条链路。等将来换存储实现,改的是入口的构造与资源配置,服务不必知道连接串或驱动类型。

不要把 context 当通用依赖容器,把 Store、Logger、Config 全部塞进 ctx.Value。context 的主要用途是取消、deadline 和请求范围内的值;长期依赖用字段与构造函数更容易检查。context 使用规则

初始化失败时,已经创建的资源也要释放 ​

最危险的路径不是全部成功,而是“数据库已经连上,后面的组件初始化失败”。若直接 return,前面打开的资源就留在进程里。

本例模拟 handler 组装失败,先关闭已打开的资源,再把初始化错误传回去。实际输出:

text
open store
open cache
close cache
close store
handler setup failed

核心做法是保存一个清理函数,并合并清理错误:

go
cleanup := func() error {
    return errors.Join(cache.Close(), store.Close())
}
if failHandler {
    return nil, errors.Join(errors.New("handler setup failed"), cleanup())
}

errors.Join 允许调用方继续通过 errors.Is/As 判断包含的错误。例子的 Close 总是成功;合并写法保留了将来真实资源关闭失败时的诊断空间。errors.Join

本文建议初始化顺序与释放责任一起设计,通常按构造的逆序清理,因为后创建的对象更可能依赖先创建的对象。但资源间没有依赖时,顺序可以按其真实需要安排;“逆序”不是对所有系统的普遍证明。

正常运行时,App 的所有者负责调用一次 Close;构造失败时,Build 自己完成已创建部分的清理。这个归属要唯一,不能一边让 App 管连接池,一边又让每个 Service 随手关闭它。示例 Close 做了简单的重复关闭保护,但不承诺并发 Close 安全;它由单个生命周期所有者调用。

构造函数里的 nil 检查有明确边界 ​

go
var p *Resource
var dep Store = p
fmt.Println(p == nil, dep == nil)

本例测试日志:

text
typed pointer nil=true interface nil=false

接口值持有动态类型与动态值;动态类型是 *Resource 时,即使指针值为 nil,接口本身也不等于 nil。因此 NewService 的 store == nil 只能抓住 nil 接口,不能自动拒绝所有“带类型 nil”。Go FAQ

不要为了一个构造函数立即引入反射通用判空器。更直接的选择是:入口保证只有成功构造的对象才传入;必要时接收具体指针;或者把“依赖实现不得是不可用的 typed nil”写成接口契约。测试应覆盖实际可能出现的错误注入方式。本文没有调用这个 typed nil 依赖,只验证比较行为。

循环依赖通常先改职责,再挑工具 ​

如果 A 构造需要 B,B 构造又需要 A,最容易想到的是先创建空对象,再 SetA/SetB。这样可能得到一段“对象存在但还不能使用”的窗口,初始化顺序也更难测试。

本文建议先查清两者为什么互相需要:

  • 它们共同维护一个不变量,可以合并为同一组件。
  • 它们需要的是对方少量能力,可以把接口放在实际消费方,由外部组装。
  • 双方都需要通知第三件事,可以提取协调者或显式事件边界。

这不意味着所有运行时对象图都必须是 DAG。回调、观察者等允许双向关系;要说清注册完成前谁可以调用谁、退出时如何解除关系。自动生成工具能检查或连线某种依赖图,不能替你定义对象何时有效。

什么时候才值得引入代码生成 ​

手工组装的成本随着重复图和分支增多而上升:多个命令都要创建一组相同基础设施,不同模式又各换一部分实现。此时可比较自动化收益与维护成本,而不是只数构造函数有多少行。

方式适合什么变化新增责任
手工构造图较小或依赖关系需要细致资源控制保持组装入口清楚、减少复制
代码生成大量可静态描述、重复的组装图固定生成器版本、保留生成输入、检查生成差异与再生成结果
运行时容器明确需要运行时注册和生命周期管理理解容器错误、初始化顺序、调试和关闭语义

Wire 是历史上常见的编译期连线工具:provider 以普通函数表达,injector 声明要组装的结果,生成代码仍是普通 Go 调用,不需要运行时反射容器。这是它的设计特点,不等于当前仍被积极维护。Wire 设计

截至 2026-10-13,google/wire 官方仓库显示 2025-08-25 归档,README 声明不再维护。 已有项目可以保留和审查生成代码,但新项目不要仅凭历史知名度把它列为默认依赖。确实选用 fork 或其他生成器时,要重新核查维护者、版本、Go 兼容性和安全边界;不能把“社区有 fork”理解为所有 fork 都得到原作者背书。Wire 官方状态

手工注入是本篇给新项目的起点。需要自动化时,先保留“参数表明依赖、错误能返回、资源能释放”这三个性质,再判断哪种工具减少了重复劳动。

资料与验证 ​

2026-10-13 在 Go 1.27.2、Linux/amd64 运行成功与部分失败的生命周期示例、typed nil 测试、race、vet。资源为内存中的教学对象,不是真实数据库或缓存;没有运行 Wire 或其他 DI 框架。生产系统还需要处理服务退出信号、等待 goroutine、停止接收新请求和有时限的关闭,这些不由构造函数注入自动解决。

完整代码 ​

保存以下文件,执行 go test -count=1 -v ./...、go test -count=1 -race ./...、go vet ./...。Example 测试会核对上面的完整输出序列。

app.go ​

代码块 · 67 行 · 点击收起 / 展开
go
package di

import (
	"context"
	"errors"
	"fmt"
	"io"
)

type Store interface {
	Get(context.Context, string) (string, error)
}
type Service struct{ store Store }

func NewService(store Store) (*Service, error) {
	if store == nil {
		return nil, errors.New("store is required")
	}
	return &Service{store: store}, nil
}
func (s *Service) Lookup(ctx context.Context, key string) (string, error) {
	return s.store.Get(ctx, key)
}

type Resource struct {
	out    io.Writer
	name   string
	closed bool
}

func Open(out io.Writer, name string) *Resource {
	fmt.Fprintln(out, "open", name)
	return &Resource{out: out, name: name}
}
func (r *Resource) Get(ctx context.Context, key string) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	return "value:" + key, nil
}
func (r *Resource) Close() error {
	if !r.closed {
		fmt.Fprintln(r.out, "close", r.name)
		r.closed = true
	}
	return nil
}

type App struct {
	Service *Service
	close   func() error
}

func (a *App) Close() error { return a.close() }
func Build(out io.Writer, failHandler bool) (*App, error) {
	store := Open(out, "store")
	service, err := NewService(store)
	if err != nil {
		return nil, errors.Join(err, store.Close())
	}
	cache := Open(out, "cache")
	cleanup := func() error { return errors.Join(cache.Close(), store.Close()) }
	if failHandler {
		return nil, errors.Join(errors.New("handler setup failed"), cleanup())
	}
	return &App{Service: service, close: cleanup}, nil
}

app_test.go ​

代码块 · 56 行 · 点击收起 / 展开
go
package di

import (
	"bytes"
	"context"
	"fmt"
	"strings"
	"testing"
)

func ExampleBuild() {
	var out bytes.Buffer
	app, err := Build(&out, false)
	if err != nil {
		panic(err)
	}
	value, err := app.Service.Lookup(context.Background(), "pen")
	fmt.Fprintln(&out, value, err)
	if err := app.Close(); err != nil {
		panic(err)
	}
	fmt.Print(out.String())
	out.Reset()
	_, err = Build(&out, true)
	fmt.Print(out.String())
	fmt.Println(err)
	// Output:
	// open store
	// open cache
	// value:pen <nil>
	// close cache
	// close store
	// open store
	// open cache
	// close cache
	// close store
	// handler setup failed
}
func TestTypedNil(t *testing.T) {
	var p *Resource
	var dep Store = p
	if dep == nil {
		t.Fatal("expected non-nil interface")
	}
	// Do not use this dependency: nil interface checks cannot reject every typed nil.
	t.Logf("typed pointer nil=%v interface nil=%v", p == nil, dep == nil)
}
func TestFailureCleanup(t *testing.T) {
	var out bytes.Buffer
	if _, err := Build(&out, true); err == nil {
		t.Fatal("expected error")
	}
	if !strings.HasSuffix(out.String(), "close cache\nclose store\n") {
		t.Fatal(out.String())
	}
}

go.mod ​

go.mod
module example.com/v2/di

go 1.27.2

保留各书来源与许可 · 示例运行条件以正文为准